Jon's Git Node
Commit 6ca3590dc1231f316d979d4dfbd4635ae47db844
Parents : fb7b42b
Author : zenith <157907903+RFnexus@users.noreply.github.com>
Date : 2026-06-08T21:59:15-04:00
Interfaces: Add shortcut for enabling / disabling from Interfaces screen, don't show stats for disabled interfaces. Allow saving currently enabled interfaces as a profile, show incoming_announce_frequency in stats, remove interface enable dialog, track changes to show restart hint, extra stats, fixea
Changes
3 files changed, 583 insertions(+), 152 deletions(-)
Diff
diff --git a/nomadnet/InterfaceProfiles.py b/nomadnet/InterfaceProfiles.py
index 91f9324..a6f918c 100644
--- a/nomadnet/InterfaceProfiles.py
+++ b/nomadnet/InterfaceProfiles.py
@@ -130,6 +130,11 @@ class InterfaceProfiles:
self.save()
return pid
+ def save_current_as_profile(self, name):
+ pid = self.create(name)
+ self.set_members(pid, sorted(self.enabled_set()))
+ return pid
+
def rename(self, pid, name):
p = self.get(pid)
if p is not None and name:
diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py
index dbbd08d..0708bd2 100644
--- a/nomadnet/ui/TextUI.py
+++ b/nomadnet/ui/TextUI.py
@@ -55,9 +55,10 @@ THEMES = {
("progress_empty", "light gray", "default", "default", "#ddd", "default"),
("interface_title", "", "", "default", "", ""),
("interface_title_selected", "bold", "", "bold", "", ""),
- ("interface_tile_focus", "light cyan,bold", "default", "bold", "#3dd,bold", "default"),
+ ("interface_tile_focus", "light blue,bold", "default", "bold", "#5af,bold", "default"),
("connected_status", "dark green", "default", "default", "dark green", "default"),
- ("disconnected_status", "dark red", "default", "default", "dark red", "default"),
+ ("disconnected_status", "dark red", "default", "default", "dark red", "default"),
+ ("disabled_status", "dark red", "default", "default", "dark red", "default"),
("placeholder", "dark gray", "default", "default", "dark gray", "default"),
("placeholder_text", "dark gray", "default", "default", "dark gray", "default"),
("error", "light red,blink", "default", "blink", "#f44,blink", "default"),
@@ -109,9 +110,10 @@ THEMES = {
("progress_empty", "dark gray", "default", "default", "#ddd", "default"),
("interface_title", "dark gray", "default", "default", "#444", "default"),
("interface_title_selected", "dark gray,bold", "default", "bold", "#444,bold", "default"),
- ("interface_tile_focus", "dark blue,bold", "default", "bold", "#06a,bold", "default"),
+ ("interface_tile_focus", "dark blue,bold", "default", "bold", "#06c,bold", "default"),
("connected_status", "dark green", "default", "default", "#4a0", "default"),
- ("disconnected_status", "dark red", "default", "default", "#a22", "default"),
+ ("disconnected_status", "dark red", "default", "default", "#a22", "default"),
+ ("disabled_status", "dark red", "default", "default", "#a22", "default"),
("placeholder", "light gray", "default", "default", "#999", "default"),
("placeholder_text", "light gray", "default", "default", "#999", "default"),
("error", "dark red,blink", "default", "blink", "#a22,blink", "default"),
@@ -168,6 +170,8 @@ GLYPHS = {
("unselected", "[ ] ", "\u25CB", "\u25CB"),
("focus_arrow", ">", "\u25B8", "\u25B8"),
("sep_dot", "|", "\u00B7", "\u00B7"),
+ ("connected", "+", "\u2713", "\U000f0201"),
+ ("disconnected", "x", "\u2717", "\U000f0202"),
("file", "[F]", "\u25a4", "\uf15b"),
("image", "[I]", "\u25a3", "\uf1c5"),
("audio", "[~]", "\u266b", "\uf1c7"),
diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py
index e8c984c..6d49738 100644
--- a/nomadnet/ui/textui/Interfaces.py
+++ b/nomadnet/ui/textui/Interfaces.py
@@ -74,6 +74,48 @@ def format_bytes(bytes_value):
else:
return f"{size:.1f} {units[unit_index]}"
+
+def format_announce_rate(hz):
+ try:
+ hz = float(hz or 0)
+ except (TypeError, ValueError):
+ return "-"
+ if hz <= 0:
+ return "-"
+ per_hour = hz * 3600
+ if per_hour < 100:
+ return "<1/hr" if per_hour < 1 else "%d/hr" % round(per_hour)
+ return "%d/min" % round(hz * 60)
+
+def format_speed(bps):
+ try:
+ bps = float(bps or 0)
+ except (TypeError, ValueError):
+ return "-"
+ try:
+ return RNS.prettyspeed(bps)
+ except Exception:
+ return "%d bps" % int(bps)
+
+def interface_mode_str(mode):
+ modes = {
+ 1: "Full", # MODE_FULL
+ 2: "Point-to-Point", # MODE_POINT_TO_POINT
+ 3: "Access Point", # MODE_ACCESS_POINT
+ 4: "Roaming", # MODE_ROAMING
+ 5: "Boundary", # MODE_BOUNDARY
+ 6: "Gateway", # MODE_GATEWAY
+ }
+ return modes.get(mode, "Full")
+
+def connection_markup(glyphs, is_connected, label=None):
+ icon = glyphs.get("connected" if is_connected else "disconnected", "")
+ if label is None:
+ label = "Connected" if is_connected else "Disconnected"
+ attr = "connected_status" if is_connected else "disconnected_status"
+ sep = " " if icon else ""
+ return (attr, "%s%s%s" % (icon, sep, label))
+
def _get_cols_rows():
return nomadnet.NomadNetworkApp.get_shared_instance().ui.screen.get_cols_rows()
@@ -589,7 +631,7 @@ INTERFACE_FIELDS = {
"type": "edit",
"label": "Target Port: ",
"default": "",
- "placeholder": "e.g., 8080",
+ "placeholder": "e.g., 4242",
"validation": ["required", "number"],
"transform": lambda x: int(x.strip()) if x.strip() else None
},
@@ -1124,7 +1166,7 @@ INTERFACE_FIELDS = {
### INTERFACE WIDGETS ####
class SelectableInterfaceItem(urwid.WidgetWrap):
- def __init__(self, parent, name, is_connected, is_enabled, iface_type, tx, rx, icon="?", iface_options=None, profiles_label=None):
+ def __init__(self, parent, name, is_connected, is_enabled, iface_type, tx, rx, icon="?", iface_options=None, profiles_label=None, announce_rate="N/A"):
self.parent = parent
self._selectable = True
self.icon = icon
@@ -1137,12 +1179,9 @@ class SelectableInterfaceItem(urwid.WidgetWrap):
if is_enabled:
enabled_txt = ("connected_status", "Enabled")
else:
- enabled_txt = ("disconnected_status", "Disabled")
+ enabled_txt = ("disabled_status", "Disabled")
- if is_connected:
- connected_txt = ("connected_status", "Connected")
- else:
- connected_txt = ("disconnected_status", "Disconnected")
+ connected_txt = connection_markup(parent.g, is_connected)
self.selection_txt = urwid.Text(" ")
self.title_widget = urwid.Text(("interface_title", f"{icon} {name}"))
@@ -1154,39 +1193,50 @@ class SelectableInterfaceItem(urwid.WidgetWrap):
self.tx_widget = urwid.Text(("value", format_bytes(tx)))
self.rx_widget = urwid.Text(("value", format_bytes(rx)))
+ self.announce_widget = urwid.Text(("value", announce_rate))
self.status_widget = urwid.Text(enabled_txt)
self.connection_widget = urwid.Text(connected_txt)
- rows = [
- urwid.Columns([
+ if is_enabled:
+ status_row = urwid.Columns([
(10, urwid.Text(("key", "Status: "))),
(10, self.status_widget),
(3, urwid.Text(" | ")),
self.connection_widget,
- ]),
+ ])
+ else:
+ status_row = urwid.Columns([
+ (10, urwid.Text(("key", "Status: "))),
+ self.status_widget,
+ ])
+
+ rows = [
+ status_row,
urwid.Columns([
(10, urwid.Text(("key", "Type:"))),
urwid.Text(("value", iface_type)),
]),
-
- urwid.Divider("-"),
-
- urwid.Columns([
- (10, urwid.Text(("key", "TX:"))),
- (15, self.tx_widget),
- (10, urwid.Text(("key", "RX:"))),
- self.rx_widget,
- ]),
]
if profiles_label is not None:
- rows.insert(2, urwid.Columns([
+ rows.append(urwid.Columns([
(10, urwid.Text(("key", "Profiles:"))),
urwid.Text(("value", profiles_label)),
]))
+ if is_enabled:
+ rows.append(urwid.Divider("-"))
+ rows.append(urwid.Columns([
+ (5, urwid.Text(("key", "TX:"))),
+ (14, self.tx_widget),
+ (5, urwid.Text(("key", "RX:"))),
+ (14, self.rx_widget),
+ (12, urwid.Text(("key", "Announces:"))),
+ self.announce_widget,
+ ]))
+
pile_contents = [title_content] + rows
pile = urwid.Pile(pile_contents)
@@ -1209,7 +1259,7 @@ class SelectableInterfaceItem(urwid.WidgetWrap):
if self.is_enabled:
self.status_widget.set_text(("connected_status", "Enabled"))
else:
- self.status_widget.set_text(("disconnected_status", "Disabled"))
+ self.status_widget.set_text(("disabled_status", "Disabled"))
def selectable(self):
return True
@@ -1242,9 +1292,17 @@ class SelectableInterfaceItem(urwid.WidgetWrap):
return None
return key
- def update_stats(self, tx, rx):
+ def update_stats(self, tx, rx, announce_rate=None):
self.tx_widget.set_text(("value", format_bytes(tx)))
self.rx_widget.set_text(("value", format_bytes(rx)))
+ if announce_rate is not None:
+ self.announce_widget.set_text(("value", announce_rate))
+
+ def update_connection(self, is_connected):
+ if is_connected == self.is_connected:
+ return
+ self.is_connected = is_connected
+ self.connection_widget.set_text(connection_markup(self.parent.g, is_connected))
class InterfaceOptionItem(urwid.WidgetWrap):
def __init__(self, parent_display, label, value, on_select=None):
@@ -1421,6 +1479,15 @@ class InterfaceFiller(urwid.WidgetWrap):
if key == "ctrl t":
self.app.ui.main_display.sub_displays.interface_display.toggle_view_mode()
return None
+ if key == "ctrl s":
+ self.app.ui.main_display.sub_displays.interface_display.save_current_profile()
+ return None
+ if key == "ctrl f":
+ self.app.ui.main_display.sub_displays.interface_display.cycle_filter()
+ return None
+ if key == "ctrl o":
+ self.app.ui.main_display.sub_displays.interface_display.toggle_focused_interface()
+ return None
if key == "ctrl a":
# add interface
self.app.ui.main_display.sub_displays.interface_display.add_interface()
@@ -2015,6 +2082,7 @@ class AddInterfaceView(urwid.WidgetWrap):
self.parent.interface_items.append(new_item)
self.parent._rebuild_list()
+ self.parent.mark_restart_pending()
self.show_message(f"Interface {name} added. Restart NomadNet to start using this interface")
@@ -2200,8 +2268,10 @@ class EditInterfaceView(AddInterfaceView):
updated_config = {
"type": interface_type,
- "interface_enabled": True
+ "interface_enabled": self.interface_config.get("interface_enabled", True),
}
+ if "enabled" in self.interface_config:
+ updated_config["enabled"] = self.interface_config["enabled"]
for field_key, field in self.fields.items():
if field_key not in ["name", "custom_parameters", "type", "subinterfaces"]:
@@ -2261,6 +2331,7 @@ class EditInterfaceView(AddInterfaceView):
break
self.parent._rebuild_list()
+ self.parent.mark_restart_pending()
self.show_message(f"Interface {new_name} updated. Restart NomadNet for these changes to take effect")
except Exception as e:
@@ -2330,42 +2401,41 @@ class ShowInterface(urwid.WidgetWrap):
footer = urwid.Pile(footer_content)
# status widgets
- self.status_text = urwid.Text(("connected_status" if self.is_enabled else "disconnected_status",
+ self.status_text = urwid.Text(("connected_status" if self.is_enabled else "disabled_status",
"Enabled" if self.is_enabled else "Disabled"))
- self.status_indicator = urwid.Text(("connected_status" if self.is_enabled else "disconnected_status",
+ self.status_indicator = urwid.Text(("connected_status" if self.is_enabled else "disabled_status",
self.parent.g['selected'] if self.is_enabled else self.parent.g[
'unselected']))
- self.connection_text = urwid.Text(("connected_status" if self.is_connected else "disconnected_status",
- "Connected" if self.is_connected else "Disconnected"))
+ self.connection_text = urwid.Text(connection_markup(self.parent.g, self.is_connected))
- self.info_rows = [
- urwid.Columns([
- (10, urwid.Text(("key", "Type:"))),
- urwid.Text(("value", f"{_get_interface_icon(self.parent.glyphset, iface_type)} {iface_type}")),
- ]),
- urwid.Columns([
+ if self.is_enabled:
+ status_columns = urwid.Columns([
(10, urwid.Text(("key", "Status:"))),
(4, self.status_indicator),
(8, self.status_text),
(3, urwid.Text(" | ")),
self.connection_text,
+ ])
+ else:
+ status_columns = urwid.Columns([
+ (10, urwid.Text(("key", "Status:"))),
+ (4, self.status_indicator),
+ self.status_text,
+ ])
+
+ self.info_rows = [
+ urwid.Columns([
+ (10, urwid.Text(("key", "Type:"))),
+ urwid.Text(("value", f"{_get_interface_icon(self.parent.glyphset, iface_type)} {iface_type}")),
]),
+ status_columns,
urwid.Divider("-")
]
- self.tx_text = urwid.Text(("value", format_bytes(self.tx)))
- self.rx_text = urwid.Text(("value", format_bytes(self.rx)))
-
- self.stat_row = urwid.Columns([
- (10, urwid.Text(("key", "TX:"))),
- (15, self.tx_text),
- (10, urwid.Text(("key", "RX:"))),
- self.rx_text,
- ])
-
- self.info_rows.append(self.stat_row)
+ for row in self._build_statistics():
+ self.info_rows.append(row)
self.info_rows.append(urwid.Divider("-"))
self.bandwidth_chart = InterfaceBandwidthChart(history_length=self.history_length, glyphset=self.parent.glyphset)
@@ -2403,7 +2473,7 @@ class ShowInterface(urwid.WidgetWrap):
])
self.disconnected_message = urwid.Filler(
- urwid.Text(("disconnected_status",
+ urwid.Text(("disabled_status",
"Charts not available - Interface is not connected"),
align="center"),
valign="top"
@@ -2539,20 +2609,160 @@ class ShowInterface(urwid.WidgetWrap):
super().__init__(self.content_box)
+ def _stat_rows_spec(self, stats):
+ # Ordered (row_id, label) pairs that apply to this interface
+ spec = [("mode", "Mode:")]
+ if stats.get("bitrate") is not None:
+ spec.append(("bitrate", "Bitrate:"))
+ spec.append(("tx", "TX:"))
+ spec.append(("rx", "RX:"))
+ if "rxs" in stats and "txs" in stats:
+ spec.append(("speed", "Speed:"))
+ if stats.get("incoming_announce_frequency") is not None:
+ spec.append(("announce", "Announces:"))
+ if stats.get("incoming_pr_frequency") is not None:
+ spec.append(("pathreq", "Path Reqs:"))
+ if stats.get("clients") is not None:
+ spec.append(("clients", "Clients:"))
+ if stats.get("peers") is not None:
+ spec.append(("peers", "Peers:"))
+ if stats.get("ifac_netname") is not None or stats.get("ifac_signature") is not None:
+ spec.append(("ifac", "Network:"))
+ if stats.get("announce_queue") is not None:
+ spec.append(("queued", "Queued:"))
+ if "held_announces" in stats:
+ spec.append(("held", "Held:"))
+ return spec
+
+ def _radio_rows_spec(self, stats):
+ spec = []
+ if "channel_load_short" in stats and "channel_load_long" in stats:
+ spec.append(("chload", "Ch. Load:"))
+ if "airtime_short" in stats and "airtime_long" in stats:
+ spec.append(("airtime", "Airtime:"))
+ if "noise_floor" in stats:
+ spec.append(("noise", "Noise Fl.:"))
+ if stats.get("battery_percent") is not None:
+ spec.append(("battery", "Battery:"))
+ if "cpu_load" in stats or "cpu_temp" in stats:
+ spec.append(("cpu", "CPU:"))
+ if "mem_load" in stats:
+ spec.append(("mem", "Memory:"))
+ return spec
+
+ def _fmt_pct(self, v):
+ try:
+ v = float(v)
+ except (TypeError, ValueError):
+ return "0"
+ if v <= 1.0:
+ v *= 100.0
+ return "%d" % int(round(v))
+
+ def _fmt_pct_pair(self, short_v, long_v):
+ return "%s%% (15s), %s%% (1h)" % (self._fmt_pct(short_v), self._fmt_pct(long_v))
+
+ def _format_stat(self, row_id, stats):
+ if row_id == "mode":
+ return interface_mode_str(stats.get("mode"))
+ if row_id == "bitrate":
+ return format_speed(stats.get("bitrate"))
+ if row_id == "tx":
+ return format_bytes(stats.get("txb", 0))
+ if row_id == "rx":
+ return format_bytes(stats.get("rxb", 0))
+ if row_id == "speed":
+ return "TX %s RX %s" % (format_speed(stats.get("txs", 0)), format_speed(stats.get("rxs", 0)))
+ if row_id == "announce":
+ return "In %s Out %s" % (format_announce_rate(stats.get("incoming_announce_frequency")),
+ format_announce_rate(stats.get("outgoing_announce_frequency")))
+ if row_id == "pathreq":
+ return "In %s Out %s" % (format_announce_rate(stats.get("incoming_pr_frequency")),
+ format_announce_rate(stats.get("outgoing_pr_frequency")))
+ if row_id == "clients":
+ return str(stats.get("clients"))
+ if row_id == "peers":
+ return "%s reachable" % stats.get("peers")
+ if row_id == "ifac":
+ netname = stats.get("ifac_netname")
+ size = stats.get("ifac_size")
+ bits = ("%d-bit IFAC" % (size * 8)) if size else "IFAC"
+ return ("%s (%s)" % (netname, bits)) if netname else bits
+ if row_id == "queued":
+ n = stats.get("announce_queue") or 0
+ return "%d announce%s" % (n, "" if n == 1 else "s")
+ if row_id == "held":
+ n = stats.get("held_announces") or 0
+ return "%d announce%s" % (n, "" if n == 1 else "s")
+ if row_id == "chload":
+ return self._fmt_pct_pair(stats.get("channel_load_short"), stats.get("channel_load_long"))
+ if row_id == "airtime":
+ return self._fmt_pct_pair(stats.get("airtime_short"), stats.get("airtime_long"))
+ if row_id == "noise":
+ nf = stats.get("noise_floor")
+ return ("%s dBm" % nf) if nf is not None else "Unknown"
+ if row_id == "battery":
+ try:
+ return "%d%% (%s)" % (int(stats.get("battery_percent")), stats.get("battery_state", "Unknown"))
+ except (TypeError, ValueError):
+ return "Unknown"
+ if row_id == "cpu":
+ load = stats.get("cpu_load")
+ temp = stats.get("cpu_temp")
+ parts = []
+ if load is not None:
+ parts.append("%s%% load" % load)
+ if temp is not None:
+ parts.append("%s°C" % temp)
+ return ", ".join(parts) if parts else "Unknown"
+ if row_id == "mem":
+ m = stats.get("mem_load")
+ return ("%s%%" % m) if m is not None else "Unknown"
+ return ""
+
+ def _build_statistics(self):
+ # Builds the live statistics section and records the dynamic widgets in
+ # self.stat_w so they can be refreshed in place each poll tick.
+ self.stat_w = {}
+
+ def stat_row(row_id, label):
+ w = urwid.Text(("value", self._format_stat(row_id, self.stats)))
+ self.stat_w[row_id] = w
+ return urwid.Columns([(12, urwid.Text(("key", label))), w])
+
+ rows = [urwid.Text(("interface_title", "Statistics"), align="left")]
+ for row_id, label in self._stat_rows_spec(self.stats):
+ rows.append(stat_row(row_id, label))
+
+ radio = self._radio_rows_spec(self.stats)
+ if radio:
+ rows.append(urwid.Divider("-"))
+ rows.append(urwid.Text(("interface_title", "Radio"), align="left"))
+ for row_id, label in radio:
+ rows.append(stat_row(row_id, label))
+
+ return rows
+
+ def _refresh_stats(self, stats):
+ for row_id, w in self.stat_w.items():
+ try:
+ w.set_text(("value", self._format_stat(row_id, stats)))
+ except Exception:
+ pass
+
def update_status_display(self):
if self.is_enabled:
self.status_indicator.set_text(("connected_status", self.parent.g['selected']))
self.status_text.set_text(("connected_status", "Enabled"))
else:
- self.status_indicator.set_text(("disconnected_status", self.parent.g['unselected']))
- self.status_text.set_text(("disconnected_status", "Disabled"))
+ self.status_indicator.set_text(("disabled_status", self.parent.g['unselected']))
+ self.status_text.set_text(("disabled_status", "Disabled"))
def update_connection_display(self, is_connected):
old_connection_state = self.is_connected
self.is_connected = is_connected
- self.connection_text.set_text(("connected_status" if self.is_connected else "disconnected_status",
- "Connected" if self.is_connected else "Disconnected"))
+ self.connection_text.set_text(connection_markup(self.parent.g, self.is_connected))
if old_connection_state != self.is_connected:
body_pile = self.frame.body.body[0].original_widget
@@ -2568,11 +2778,8 @@ class ShowInterface(urwid.WidgetWrap):
if chart_index is not None:
if self.is_connected:
new_widget = self.horizontal_charts if self.is_horizontal else self.vertical_charts
- if not self.started:
- self.start()
else:
new_widget = self.disconnected_box
- self.started = False
body_pile.contents[chart_index] = (new_widget, body_pile.options())
@@ -2600,7 +2807,7 @@ class ShowInterface(urwid.WidgetWrap):
if hasattr(self.parent.app.ui, 'loop') and self.parent.app.ui.loop is not None:
self.parent.app.ui.loop.draw_screen()
- self.show_restart_required_message()
+ self.parent.mark_restart_pending()
except Exception as e:
self.show_error_message(f"Error updating interface: {str(e)}")
@@ -2770,65 +2977,70 @@ class ShowInterface(urwid.WidgetWrap):
break
def start(self):
- if not self.started and self.is_connected:
+ # Poll while the detail view is open, regardless of connection state, so
+ # that the connection indicator and live stats keep refreshing and a
+ # dropped link can be seen to come back up.
+ if not self.started:
self.started = True
self.parent.app.ui.loop.set_alarm_in(1, self.update_bandwidth_charts)
+ def _show_disconnect_overlay(self):
+ # Uses its own overlay object so it never collides with the interface
+ # list's disconnect overlay handling while both poll loops are running.
+ if getattr(self, "_disconnect_overlay", None) is not None and self.parent.widget is self._disconnect_overlay:
+ return
+ dialog_text = urwid.Pile([
+ urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"),
+ urwid.Text("Waiting to Reconnect...", align="center"),
+ ])
+ dialog_box = urwid.LineBox(urwid.Filler(dialog_text))
+ self._disconnect_overlay = urwid.Overlay(
+ dialog_box, self, align='center', width=35, valign='middle', height=4)
+ self.parent.widget = self._disconnect_overlay
+ self.parent.app.ui.main_display.update_active_sub_display()
+
def update_bandwidth_charts(self, loop, user_data):
if not self.started:
return
try:
- interface_stats = self.parent.app.interface_stats
- stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']}
- stats = stats_lookup.get(self.iface_name, {})
+ # The interface list's poll loop owns the actual stats fetch; when the
+ # shared RNS instance drops it flags _rns_disconnected, which we surface
+ # here too instead of silently charting stale data.
+ if getattr(self.parent, "_rns_disconnected", False):
+ self._show_disconnect_overlay()
+ else:
+ if getattr(self, "_disconnect_overlay", None) is not None and self.parent.widget is self._disconnect_overlay:
+ self.parent.widget = self
+ self.parent.app.ui.main_display.update_active_sub_display()
- tx = stats.get("txb", self.tx)
- rx = stats.get("rxb", self.rx)
+ interface_stats = self.parent.app.interface_stats
+ stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']}
+ stats = stats_lookup.get(self.iface_name, {})
- new_connection_status = stats.get("status", False)
- if new_connection_status != self.is_connected:
- self.update_connection_display(new_connection_status)
+ tx = stats.get("txb", self.tx)
+ rx = stats.get("rxb", self.rx)
- if not self.is_connected:
- return
+ new_connection_status = stats.get("status", False)
+ if new_connection_status != self.is_connected:
+ self.update_connection_display(new_connection_status)
- self.tx_text.set_text(("value", format_bytes(tx)))
- self.rx_text.set_text(("value", format_bytes(rx)))
+ self._refresh_stats(stats)
- self.bandwidth_chart.update(rx, tx)
+ if self.is_connected:
+ self.bandwidth_chart.update(rx, tx)
- rx_chart, tx_chart, peak_rx, peak_tx = self.bandwidth_chart.get_charts(height=8)
+ rx_chart, tx_chart, peak_rx, peak_tx = self.bandwidth_chart.get_charts(height=8)
- self.rx_chart_text.set_text(rx_chart)
- self.tx_chart_text.set_text(tx_chart)
- self.rx_peak_text.set_text(f"Peak: {peak_rx}")
- self.tx_peak_text.set_text(f"Peak: {peak_tx}")
+ self.rx_chart_text.set_text(rx_chart)
+ self.tx_chart_text.set_text(tx_chart)
+ self.rx_peak_text.set_text(f"Peak: {peak_rx}")
+ self.tx_peak_text.set_text(f"Peak: {peak_tx}")
- self.tx = tx
- self.rx = rx
+ self.tx = tx
+ self.rx = rx
except Exception as e:
- if not hasattr(self.parent,
- 'disconnect_overlay') or self.parent.widget is not self.parent.disconnect_overlay:
- dialog_text = urwid.Pile([
- urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"),
- urwid.Text("Waiting to Reconnect...", align="center")
- ])
- dialog_content = urwid.Filler(dialog_text)
- dialog_box = urwid.LineBox(dialog_content)
-
- self.parent.disconnect_overlay = urwid.Overlay(
- dialog_box,
- self,
- align='center',
- width=35,
- valign='middle',
- height=4
- )
-
- self.parent.widget = self.parent.disconnect_overlay
- self.parent.app.ui.main_display.update_active_sub_display()
- self.started = False
+ self._show_disconnect_overlay()
finally:
if self.started:
loop.set_alarm_in(1, self.update_bandwidth_charts)
@@ -2842,21 +3054,27 @@ class ShowInterface(urwid.WidgetWrap):
self.parent.switch_to_edit_interface(self.iface_name)
class InterfaceTile(urwid.WidgetWrap):
- def __init__(self, parent, name, is_connected, is_enabled, iface_type, icon):
+ def __init__(self, parent, name, is_connected, is_enabled, iface_type, icon, announce_rate="0"):
self.parent = parent
self.name = name
self.icon = icon
self.g = parent.g
- self.dot_style = "connected_status" if is_enabled else "disconnected_status"
+ self.dot_style = "connected_status" if is_enabled else "disabled_status"
self.dot = self.g['selected'] if is_enabled else self.g['unselected']
self.title_widget = urwid.Text("", wrap="clip")
typ = urwid.Text(("value", iface_type), wrap="clip")
- status = urwid.Text([
- (self.dot_style, "Enabled" if is_enabled else "Disabled"),
- ("value", f" {self.g['sep_dot']} "),
- ("connected_status" if is_connected else "disconnected_status", "Conn." if is_connected else "Disc."),
- ], wrap="clip")
- inner = urwid.Pile([self.title_widget, typ, status])
+ if is_enabled:
+ status = urwid.Text([
+ (self.dot_style, "Enabled"),
+ ("value", f" {self.g['sep_dot']} "),
+ connection_markup(self.g, is_connected, "Conn." if is_connected else "Disc."),
+ ], wrap="clip")
+ else:
+ status = urwid.Text([(self.dot_style, "Disabled")], wrap="clip")
+ inner_items = [self.title_widget, typ, status]
+ if is_enabled:
+ inner_items.append(urwid.Text([("key", "Announces "), ("value", announce_rate)], wrap="clip"))
+ inner = urwid.Pile(inner_items)
self.box = urwid.LineBox(
urwid.Padding(inner, left=1, right=1),
tlcorner="╭", trcorner="╮", blcorner="╰", brcorner="╯",
@@ -2987,11 +3205,42 @@ class ProfilesView(urwid.WidgetWrap):
def select(self, pid):
if pid == "__default__":
- self.profiles.select_default()
+ if self.profiles.default_members is None:
+ return
+ count = len(self.profiles.default_members)
+ title = "Switch to Default"
+ msg = "Restore your saved manual setup?\nThis enables %d interface%s and disables the rest." % (count, "" if count == 1 else "s")
else:
- self.profiles.select_profile(pid)
- self.refresh(focus_pid=pid)
- self.status.set_text(("connected_status", "Restart NomadNet for changes to take effect"))
+ p = self.profiles.get(pid)
+ if p is None:
+ return
+ count = self.profiles.member_count(p)
+ title = "Switch Profile"
+ msg = "Switch to profile '%s'?\nThis enables its %d interface%s and disables the rest." % (p["name"] or "(unnamed)", count, "" if count == 1 else "s")
+
+ def yes(_b):
+ self.dismiss_dialog()
+ if pid == "__default__":
+ self.profiles.select_default()
+ else:
+ self.profiles.select_profile(pid)
+ self.parent.mark_restart_pending()
+ self.refresh(focus_pid=pid)
+ self.status.set_text(("connected_status", "Restart NomadNet for changes to take effect"))
+
+ def no(_b):
+ self.dismiss_dialog()
+
+ pile = urwid.Pile([
+ urwid.Text(msg, align="center"),
+ urwid.Divider(),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=yes)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=no)),
+ ]),
+ ])
+ self._overlay(DialogLineBox(urwid.Filler(pile, urwid.TOP), parent=self, title=title), height=10)
def _overlay(self, dialog, height=8, width=50):
frame = self.app.ui.main_display.frame
@@ -3013,6 +3262,20 @@ class ProfilesView(urwid.WidgetWrap):
self._prompt("New Profile", edit, ok)
+ def save_current(self):
+ n = len(self.profiles.enabled_set())
+ edit = ReadlineEdit(caption="Name (%d enabled): " % n)
+
+ def ok(_b):
+ val = edit.get_edit_text().strip()
+ self.dismiss_dialog()
+ if val:
+ pid = self.profiles.save_current_as_profile(val)
+ self.refresh(focus_pid=pid)
+ self.status.set_text(("connected_status", "Saved current setup (%d interface%s) as a profile" % (n, "" if n == 1 else "s")))
+
+ self._prompt("Save Current as Profile", edit, ok)
+
def rename_profile(self):
w = self._focused()
if w is None or w.pid is None:
@@ -3075,6 +3338,8 @@ class ProfilesView(urwid.WidgetWrap):
self.parent.show_interfaces(); return None
if key == "ctrl a":
self.new_profile(); return None
+ if key == "ctrl s":
+ self.save_current(); return None
if key == "ctrl e":
self.rename_profile(); return None
if key == "ctrl x":
@@ -3099,6 +3364,11 @@ class InterfaceDisplay:
self.active_tab = "interfaces"
self.iface_view_mode = "rows"
+ self.iface_filter = "all"
+ self.total_interfaces = 0
+ self.restart_pending = False
+ self._notice_shown = False
+ self._rns_disconnected = False
self.profiles_view = None
self._build_interface_items()
@@ -3106,11 +3376,12 @@ class InterfaceDisplay:
self.list_walker = urwid.SimpleFocusListWalker(self._interface_list_contents())
self.list_box = urwid.ListBox(self.list_walker)
- self.body_rows = max(3, self.list_rows - 3)
+ self.body_rows = max(3, self.list_rows - 4)
self.box_adapter = urwid.BoxAdapter(self.list_box, self.body_rows)
self.tabs_pile = urwid.Pile([
('pack', self._make_tab_bar("interfaces")),
+ ('pack', self._make_filter_bar()),
('pack', urwid.Divider()),
self.box_adapter,
])
@@ -3118,13 +3389,41 @@ class InterfaceDisplay:
self.shortcuts_display = InterfaceDisplayShortcuts(self.app)
self.widget = self.interfaces_display
+ def _empty_message(self):
+ if self.iface_filter != "all" and self.total_interfaces > 0:
+ return "No %s interfaces." % self.iface_filter
+ return "No interfaces found. Press Ctrl + A to add a new interface "
+
def _interface_list_contents(self):
if not self.interface_items:
- return [urwid.Text(("interface_title", "No interfaces found. Press Ctrl + A to add a new interface "), align="center")]
+ return [urwid.Text(("interface_title", self._empty_message()), align="center")]
return list(self.interface_items)
+ def _make_filter_bar(self):
+ cols = [('weight', 1, urwid.Text(""))]
+ for key, label in (("all", "All"), ("enabled", "Enabled"), ("disabled", "Disabled")):
+ sel = self.iface_filter == key
+ mark = self.g['selected'] if sel else self.g['unselected']
+ style = "interface_tile_focus" if sel else "interface_title"
+ cols.append(('pack', ClickableIcon((style, "%s %s " % (mark, label)), on_click=lambda k=key: self._set_filter(k))))
+ cols.append(('weight', 1, urwid.Text("")))
+ return urwid.Columns(cols, dividechars=0)
+
+ def _set_filter(self, key):
+ if key == self.iface_filter:
+ return
+ self.iface_filter = key
+ self._build_interface_items()
+ self._rebuild_list()
+ self.app.ui.main_display.update_active_sub_display()
+
+ def cycle_filter(self):
+ order = ["all", "enabled", "disabled"]
+ i = order.index(self.iface_filter) if self.iface_filter in order else 0
+ self._set_filter(order[(i + 1) % len(order)])
+
def _make_tab_bar(self, active):
- iface = f" Interfaces ({len(self.interface_items)}) "
+ iface = f" Interfaces ({self.total_interfaces}) "
prof = " Profiles "
if active == "interfaces":
iface_w = ClickableIcon(("interface_title_selected", "["+iface+"]"), on_click=self.show_interfaces)
@@ -3141,7 +3440,7 @@ class InterfaceDisplay:
], dividechars=0)
def _build_grid_listbox(self):
- tiles = self.interface_tiles if self.interface_tiles else [urwid.Text("No interfaces found. Press Ctrl + A to add a new interface ", align="center")]
+ tiles = self.interface_tiles if self.interface_tiles else [urwid.Text(self._empty_message(), align="center")]
self.iface_grid = urwid.GridFlow(tiles, cell_width=34, h_sep=2, v_sep=1, align="center")
return urwid.ListBox(urwid.SimpleFocusListWalker([self.iface_grid]))
@@ -3208,7 +3507,7 @@ class InterfaceDisplay:
if new_rows != self.terminal_rows or new_cols != self.terminal_cols:
self.terminal_cols, self.terminal_rows = new_cols, new_rows
self.list_rows = self.terminal_rows - self.iface_row_offset
- self.body_rows = max(3, self.list_rows - 3)
+ self.body_rows = max(3, self.list_rows - 4 - (1 if self.restart_pending else 0))
self.box_adapter.height = self.body_rows
@@ -3217,45 +3516,65 @@ class InterfaceDisplay:
if self.started:
loop.set_alarm_in(5, self.check_terminal_size)
+ def _show_disconnect_overlay(self):
+ # Surfaced when the shared RNS instance (rnsd) we are attached to goes away.
+ if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay:
+ return
+ if self.widget is not self.interfaces_display:
+ return
+ dialog_text = urwid.Pile([
+ urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"),
+ urwid.Text("Waiting to Reconnect...", align="center"),
+ ])
+ dialog_box = urwid.LineBox(urwid.Filler(dialog_text))
+ self.disconnect_overlay = urwid.Overlay(
+ dialog_box,
+ self.interfaces_display,
+ align='center',
+ width=35,
+ valign='middle',
+ height=4,
+ )
+ self.widget = self.disconnect_overlay
+ self.app.ui.main_display.update_active_sub_display()
+
def poll_stats(self, loop, user_data):
self.poll_scheduler = True
try:
- if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay:
- self.widget = self.interfaces_display
- self.app.ui.main_display.update_active_sub_display()
+ # React to the outcome of the previous (asynchronous) stats fetch. The
+ # fetch runs in a daemon thread so the UI never blocks on the RPC round
+ # trip; the thread cannot raise into this loop, so it reports failure via
+ # the _rns_disconnected flag instead.
+ if self._rns_disconnected:
+ self._show_disconnect_overlay()
+ else:
+ if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay:
+ self.widget = self.interfaces_display
+ self.app.ui.main_display.update_active_sub_display()
- def job(): self.app.interface_stats = self.app.rns.get_interface_stats()
+ interface_stats = self.app.interface_stats
+ stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']}
+ for item in self.interface_items:
+ # use interface name as the key
+ stats_for_interface = stats_lookup.get(item.name)
+ if stats_for_interface:
+ tx = stats_for_interface.get("txb", 0)
+ rx = stats_for_interface.get("rxb", 0)
+ announce_rate = format_announce_rate(stats_for_interface.get("incoming_announce_frequency", 0))
+ item.update_stats(tx, rx, announce_rate)
+ item.update_connection(stats_for_interface.get("status", False))
+ else:
+ item.update_connection(False)
+
+ def job():
+ try:
+ self.app.interface_stats = self.app.rns.get_interface_stats()
+ self._rns_disconnected = False
+ except Exception:
+ self._rns_disconnected = True
threading.Thread(target=job, daemon=True).start()
- interface_stats = self.app.interface_stats
- stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']}
- for item in self.interface_items:
- # use interface name as the key
- stats_for_interface = stats_lookup.get(item.name)
- if stats_for_interface:
- tx = stats_for_interface.get("txb", 0)
- rx = stats_for_interface.get("rxb", 0)
- item.update_stats(tx, rx)
except Exception as e:
- if not hasattr(self, 'disconnect_overlay') or self.widget is not self.disconnect_overlay:
- dialog_text = urwid.Pile([
- urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"),
- urwid.Text(("Waiting to Reconnect..."), align="center")
- ])
- dialog_content = urwid.Filler(dialog_text)
- dialog_box = urwid.LineBox(dialog_content)
-
- self.disconnect_overlay = urwid.Overlay(
- dialog_box,
- self.interfaces_display,
- align='center',
- width=35,
- valign='middle',
- height=4
- )
-
- if self.widget is self.interfaces_display:
- self.widget = self.disconnect_overlay
- self.app.ui.main_display.update_active_sub_display()
+ self._show_disconnect_overlay()
finally:
if self.started:
loop.set_alarm_in(1, self.poll_stats)
@@ -3424,6 +3743,7 @@ class InterfaceDisplay:
self.paste_status.set_text(("error", " Failed: " + str(e)))
return
+ self.mark_restart_pending()
self.dismiss_dialog()
self.show_interfaces()
@@ -3445,6 +3765,7 @@ class InterfaceDisplay:
try: self.app.interface_profiles.remove_interface(interface_name)
except Exception: pass
+ self.mark_restart_pending()
self._build_interface_items()
self._rebuild_list()
self.dismiss_dialog()
@@ -3519,6 +3840,7 @@ class InterfaceDisplay:
processed_interfaces[interface_name] = interface_data
+ self.total_interfaces = len(processed_interfaces)
self.app.interface_stats = self.app.rns.get_interface_stats()
stats_lookup = {interface['short_name']: interface for interface in self.app.interface_stats['interfaces']}
@@ -3527,6 +3849,11 @@ class InterfaceDisplay:
for interface_name, interface_data in processed_interfaces.items():
is_enabled = str(interface_data.get("enabled")).lower() not in ('false', 'off', 'no', '0') and str(interface_data.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0')
+ if self.iface_filter == "enabled" and not is_enabled:
+ continue
+ if self.iface_filter == "disabled" and is_enabled:
+ continue
+
iface_type = interface_data.get("type", "Unknown")
icon = _get_interface_icon(self.glyphset, iface_type)
@@ -3536,10 +3863,12 @@ class InterfaceDisplay:
tx = stats_for_interface.get("txb", 0)
rx = stats_for_interface.get("rxb", 0)
is_connected = stats_for_interface["status"]
+ announce_rate = format_announce_rate(stats_for_interface.get("incoming_announce_frequency", 0))
else:
tx = 0
rx = 0
is_connected = False
+ announce_rate = "-"
profiles_label = profiles.label_for(interface_name) if profiles is not None else None
@@ -3552,7 +3881,8 @@ class InterfaceDisplay:
tx=tx,
rx=rx,
icon=icon,
- profiles_label=profiles_label
+ profiles_label=profiles_label,
+ announce_rate=announce_rate
)
self.interface_items.append(item)
@@ -3564,12 +3894,104 @@ class InterfaceDisplay:
is_enabled=is_enabled,
iface_type=iface_type,
icon=icon,
+ announce_rate=announce_rate,
)
self.interface_tiles.append(tile)
def _rebuild_list(self):
self._set_iface_body()
self.tabs_pile.contents[0] = (self._make_tab_bar("interfaces"), self.tabs_pile.options('pack'))
+ self.tabs_pile.contents[1] = (self._make_filter_bar(), self.tabs_pile.options('pack'))
+
+ def mark_restart_pending(self):
+ self.restart_pending = True
+ if self._notice_shown:
+ return
+ try:
+ mark = "!" if self.glyphset == "plain" else "⚠"
+ banner = urwid.AttrMap(
+ urwid.Text("%s Interfaces have been updated. Restart required" % mark, align="center"),
+ "warning_text")
+ self.tabs_pile.contents.insert(2, (banner, self.tabs_pile.options('pack')))
+ self._notice_shown = True
+ self.body_rows = max(3, self.body_rows - 1)
+ self.box_adapter.height = self.body_rows
+ except Exception:
+ pass
+
+ def _focus_interface(self, name):
+ try:
+ if self.iface_view_mode == "grid":
+ for i, t in enumerate(self.interface_tiles):
+ if getattr(t, "name", None) == name:
+ self.iface_grid.focus_position = i
+ return
+ else:
+ body = self.box_adapter._original_widget.body
+ for i, w in enumerate(body):
+ if getattr(w, "name", None) == name:
+ self.box_adapter._original_widget.focus_position = i
+ return
+ except Exception:
+ pass
+
+ def toggle_focused_interface(self):
+ name = self._focused_interface_name()
+ if name is None:
+ return
+ interfaces = self.app.rns.config['interfaces']
+ if name not in interfaces:
+ return
+ iface = interfaces[name]
+ is_enabled = str(iface.get("enabled")).lower() not in ('false', 'off', 'no', '0') and \
+ str(iface.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0')
+ value = "false" if is_enabled else "true"
+ iface["interface_enabled"] = value
+ if "enabled" in iface:
+ iface["enabled"] = value
+ try:
+ self.app.rns.config.write()
+ except Exception:
+ return
+ self.mark_restart_pending()
+ self._build_interface_items()
+ self._rebuild_list()
+ self._focus_interface(name)
+ self.app.ui.main_display.update_active_sub_display()
+
+ def save_current_profile(self):
+ profiles = getattr(self.app, "interface_profiles", None)
+ if profiles is None:
+ return
+ n = len(profiles.enabled_set())
+ edit = ReadlineEdit(caption="Name: ")
+
+ def ok(_b):
+ val = edit.get_edit_text().strip()
+ self.dismiss_dialog()
+ if val:
+ profiles.save_current_as_profile(val)
+ self.show_interfaces()
+
+ pile = urwid.Pile([
+ urwid.Text("Save the %d currently enabled interface%s as a new profile." % (n, "" if n == 1 else "s"), align="left"),
+ urwid.Divider(),
+ edit,
+ urwid.Divider(),
+ urwid.Columns([
+ (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=ok)),
+ (urwid.WEIGHT, 0.1, urwid.Text("")),
+ (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=lambda _b: self.dismiss_dialog())),
+ ]),
+ ])
+ dialog = DialogLineBox(urwid.Filler(pile, valign="top"), parent=self, title="Save Current as Profile")
+ overlay = urwid.Overlay(
+ dialog, self.interfaces_display,
+ align="center", width=("relative", 60),
+ valign="middle", height=("relative", 45), min_width=44, min_height=9,
+ )
+ self.widget = overlay
+ self.app.ui.main_display.update_active_sub_display()
def open_config_editor(self):
import platform
@@ -3602,7 +4024,7 @@ class InterfaceDisplay:
class InterfaceDisplayShortcuts:
def __init__(self, app):
self.app = app
- self.default_shortcuts = "[C-a] Add [C-e] Edit [C-x] Remove [Enter] Show [C-t] Grid/Rows [C-w] Editor [Tab] Profiles"
+ self.default_shortcuts = "[C-a] Add [C-e] Edit [C-x] Remove [C-o] On/Off [Enter] Show [C-f] Filter [C-s] Save Profile [C-t] Grid/Rows [Tab] Profiles"
self.current_shortcuts = self.default_shortcuts
self.widget = urwid.AttrMap(
urwid.Text(self.current_shortcuts),
@@ -3629,5 +4051,5 @@ class InterfaceDisplayShortcuts:
self.update_shortcuts(edit_shortcuts)
def set_profiles_tab_shortcuts(self):
- profiles_shortcuts = "[Up/Down] Navigate [Space] Select Profile [C-a] New [C-e] Rename [C-x] Delete [Tab] Interfaces"
+ profiles_shortcuts = "[Space] Select [C-a] New [C-s] Save Current [C-e] Rename [C-x] Delete [Tab] Interfaces"
self.update_shortcuts(profiles_shortcuts)
\ No newline at end of file
Served by rngit 1.5.4 - Generated in 0.05s